← Back to posts

The Idea

Assetto Corsa is a racing simulator known for its obsessive attention to detail — real car physics, laser-scanned tracks, and highly moddable internals. Cars in AC have functioning dashboard screens driven by Lua scripts. I asked a simple question: what if one of those screens showed Doom?

Not a screenshot. Not a GIF. Live, running Doom — framebuffer streaming in real time from a headless process into the sim while you drive.

The result: a working prototype that renders Doom inside AC at approximately 15-20fps using a three-layer pipeline I built from scratch. Here's how it works and what I had to fight through to get there.


The Architecture

The system has three layers communicating through Windows shared memory:

[ doomgeneric.exe ]  →  [ Windows MMF ]  →  [ AC CSP Lua App ]
  headless Doom           shared memory        in-game renderer
  writes frames           1MB named region     reads & draws pixels

Layer 1 — Headless Doom I used doomgeneric, a minimal Doom port designed for porting. Instead of rendering to a screen, I implemented the platform layer (doomgeneric_mmf.c) to write each frame into a Windows Memory-Mapped File. The frame format is simple: a 12-byte header (frame counter, width, height) followed by 320×200×4 bytes of BGRA pixel data.

Doom's internal framebuffer is 8-bit paletted — palette indices, not colors. Color conversion happens in I_SetPalette() which builds a DG_Palette[256] lookup table. On every DG_DrawFrame() call, 64,000 palette lookups convert the indexed buffer to BGRA and write it into the MMF.

Layer 2 — The Memory-Mapped File A named Windows MMF (DoomDashFrame, ~1MB) acts as the shared channel. Doom creates it on startup; the AC Lua app opens it. A frame counter in the header lets the reader detect new frames without polling every byte.

Layer 3 — CSP Lua App A Lua script running inside Assetto Corsa reads the MMF via CSP's ac.readMemoryMappedFile() API, converts the pixel data using run-length encoding, and renders it into a floating in-game app window using ui.drawRectFilled() calls.


What Made This Hard

The CSP Sandbox Is Hostile

Assetto Corsa's Custom Shaders Patch (CSP) extends the game with a Lua scripting system, but different script contexts have wildly different capabilities. My first instinct was to render Doom directly onto the car's physical dashboard LCD screen using a SCRIPTABLE_DISPLAY script — the cleanest solution. CSP explicitly blocks shared memory access in that context:

Script of this type can't access shared memory files

Car scripts (SCRIPT_ type)? Same block. The Lua FFI table exists in some contexts, but ffi.cdef and ffi.load are stripped — meaning I can't declare Win32 API functions or load DLLs to call them directly. Every instinct about "just use the OS API" hits a wall.

The app context (apps/lua/) turned out to be the only context with ac.readMemoryMappedFile() access. Getting there required a separate investigation — AC app scripts don't tick unless they have a visible window, the Lua filename must match the app folder name exactly, and the Lua cache at assettocorsa/cache/lua/ silently serves stale bytecode if you don't clear it after manifest changes.

The MMF Naming Problem

CSP's ac.connect() function creates shared memory regions but they don't appear as named Windows kernel objects. Process Hacker confirmed this — acs.exe had no named MMF handles for any ac.connect() region. The naming scheme CSP uses internally (AcTools.CSP.Name.v0) doesn't map to anything Doom can open from outside the process. I had to use ac.readMemoryMappedFile() reading a standard Windows MMF that Doom creates directly.

Image File Approach Was a Dead End

Before settling on the MMF pipeline, I tried having Doom write BMP frames to disk and loading them with ui.drawImage(). This worked for a single static frame but AC caches images aggressively by filename. The file changes on disk but AC never re-reads it. Cache-busting with query strings (?v=N) works for URLs but not local file paths. Rotating through multiple filenames (doom_0.bmp, doom_1.bmp...) failed for the same reason — each filename gets cached after first load.

The Canvas Draw Call Limit

The ideal rendering approach was ui.ExtraCanvas — a persistent offscreen texture that survives between frames, eliminating the need to redraw everything every frame. Testing revealed an internal draw call limit of roughly 10,000-15,000 per canvas update. For complex Doom scenes with high color variety, even run-length encoded output exceeds this, rendering only a partial image. Splitting into multiple canvas strips failed because each update() call clears the canvas — earlier strips get wiped when drawing later ones.

The solution was drawing directly in windowMain() which has a much higher draw call budget, combined with frame skipping to control the cost.


The Rendering Solution

Doom's 320×200 frame contains up to 64,000 pixels. Calling ui.drawRectFilled() 64,000 times per frame tanks AC's framerate to the low 40s. The fix is horizontal run-length encoding: scan each row left-to-right, group consecutive pixels of identical color into single wider rectangles, and emit one draw call per color run instead of one per pixel.

for y = 0, FULL_H - 1, 2 do          -- sample every other row
    local runStart, runR, runG, runB = 0, 0, 0, 0
    for x = 0, FULL_W - 1, 2 do      -- sample every other column
        local r, g, b = samplePixel(x, y)
        if colorChanged(r, g, b) then
            drawRect(runStart, y, x, r, g, b)
            runStart, runR, runG, runB = x, r, g, b
        end
    end
    drawRect(runStart, y, FULL_W, runR, runG, runB)
end

Doom's palette-based renderer naturally produces large flat-color areas — walls, floors, sky — so runs are long and draw calls are few. Typical complex scenes generate 3,000-8,000 draw calls instead of 64,000. Combined with sampling every other pixel (reducing Lua iterations from 64,000 to 16,000) and a frame skip of 4, AC runs at full framerate while Doom updates at ~15-20fps.


Results

The Doom window floats over the dashboard and can be positioned over the physical LCD screen of any car. It's not rendered onto the 3D mesh — that requires either a future CSP update allowing SCRIPTABLE_DISPLAY memory access, or a native C++ CSP plugin.


What's Next

The input forwarding infrastructure already exists — doomgeneric_mmf.c has a key queue ring buffer, it just isn't being written to yet. A second MMF could forward wheel button presses or keyboard inputs from AC into Doom's input queue. Playable Doom while racing is within reach.

Longer term, getting Doom rendered directly onto the car's physical dashboard mesh — replacing the mirror camera with a Doom viewport — would require either petitioning the CSP developers for a shared memory hook in the display context, or writing a native plugin. That's the next mountain.


Tech Stack